查看原文
其他

用Linux内核的瑞士军刀-eBPF实现socket转发offload

dog250 Linux阅码场 2022-12-14

我们已经对eBPF将网络转发offload到XDP(eXpress Data Path)耳熟能详,作为Linux内核的一把 “瑞士军刀” ,eBPF能做的事情可不止一件,它是一个多面手。

实现一个基于XDP_eBPF的学习型网桥之后,我们来看看如何基于eBPF实现socket转发的offload。

socket数据offload问题

通过代理服务器在两个TCP接连之间转发数据是一个非常常见的需求,特别是在CDN的场景下,然而这个代理服务器也是整条路径中的瓶颈之所在,代理服务器的七层转发行为极大地消耗着单机性能,所以,通过代理服务器的七层转发的优化,是一件必须要做的事。

所以,问题来了, eBPF能不能将代理程序的数据转发offload到内核呢? 如果可以做到,这就意味着这个offload可以达到和XDP offload相近的功效:

  • 减少上下文切换,缩短转发逻辑路径,释放host CPU。

这个问题之所以很重要亟待解决,是因为现在的很多机制都不完美:

  • 传统的read/write方式需要两次系统调用和两次数据拷贝。 

  • 稍微新些的sendfile方式不支持socket到socket的转发,且仍需要在唤醒的进程上下文中进行系统调用

     

  • DPDK以及各种分散/聚集IO,零拷贝技术需要对应用进行比较大的重构,太复杂。

  • ...

sockmap的引入

Linux 4.14内核带来了sockmap,详见下面的

lwn:BPF: sockmap and sk redirect support: https://lwn.net/Articles/731133/ 

还有下面的blog也很不错:

https://blog.cloudflare.com/sockmap-tcp-splicing-of-the-future/

又是eBPF!这意味着用sockmap做redirect注定简单,小巧!

我们先看下sockmap相对于上述的转发机制有什么不同,下面是个原理图:

sockmap的实现非常简单,它通过替换skdataready回调函数的方式接管整个数据面的转发逻辑处理。

按照常规,skdataready是内核协议栈和进程上下文的socket之间的数据通道接口,它将数据从内核协议栈交接给了持有socket的进程:

常规处理的skdataready回调函数的控制权转移是通过一次wakeup操作来完成的,这意味着一次上下文的切换。

而sockmap的处理与此不同,sockmap通过一种称为 Stream Parser 的机制,将数据包的控制权转移到eBPF处理程序,而eBPF程序可以实现数据流的Redirect,这就实现了socket数据之间的offload短路处理:

关于 Stream Parser ,详情参见其内核文档:https://www.kernel.org/doc/Documentation/networking/strparser.txt

实例演示

任何机制能实际run起来才是一个真正的起点,现在又到了实例演示的环节。

我们先从一个简单proxy程序开始,然后我们为它注入基于eBPF的sockmap逻辑,实现proxy的offload转发,从而理解整个过程。

我们的proxy程序非常简单,你可以将它理解成一个socket Bridge,它从一个连接接收数据并简单地将该数据转发到另一个连接,稍微修改一下即可实现socket Hub/Switch以及Service mesh。

socket Bridge代码如下:

  1. // proxy.c

  2. // gcc proxy.c -o proxy

  3. #include <stdio.h>

  4. #include <stdlib.h>

  5. #include <string.h>

  6. #include <unistd.h>

  7. #include <sys/types.h>

  8. #include <netinet/in.h>

  9. #include <sys/socket.h>

  10. #include <sys/select.h>

  11. #include <netdb.h>

  12. #include <signal.h>


  13. #define MAXSIZE 100


  14. char buf[MAXSIZE];

  15. int proxysd1, proxysd2;


  16. static void int_handler(int a)

  17. {

  18. close(proxysd1);

  19. close(proxysd2);

  20. exit(0);

  21. }


  22. int main(int argc, char *argv[])

  23. {

  24. int ret;

  25. struct sockaddr_in proxyaddr1, proxyaddr2;

  26. struct hostent *proxy1, *proxy2;

  27. unsigned short port1, port2;

  28. fd_set rset;

  29. int maxfd = 10, n;


  30. if (argc != 5) {

  31. exit(1);

  32. }


  33. signal(SIGINT, int_handler);


  34. FD_ZERO(&rset);


  35. proxysd1 = socket(AF_INET, SOCK_STREAM, 0);

  36. proxysd2 = socket(AF_INET, SOCK_STREAM, 0);


  37. proxy1 = gethostbyname(argv[1]);

  38. port1 = atoi(argv[2]);


  39. proxy2 = gethostbyname(argv[3]);

  40. port2 = atoi(argv[4]);


  41. bzero(&proxyaddr1, sizeof(struct sockaddr_in));

  42. proxyaddr1.sin_family = AF_INET;

  43. proxyaddr1.sin_port = htons(port1);

  44. proxyaddr1.sin_addr = *((struct in_addr *)proxy1->h_addr);


  45. bzero(&proxyaddr2, sizeof(struct sockaddr_in));

  46. proxyaddr2.sin_family = AF_INET;

  47. proxyaddr2.sin_port = htons(port2);

  48. proxyaddr2.sin_addr = *((struct in_addr *)proxy2->h_addr);


  49. connect(proxysd1, (struct sockaddr *)&proxyaddr1, sizeof(struct sockaddr));

  50. connect(proxysd2, (struct sockaddr *)&proxyaddr2, sizeof(struct sockaddr));


  51. while (1) {

  52. FD_SET(proxysd1, &rset);

  53. FD_SET(proxysd2, &rset);

  54. select(maxfd, &rset, NULL, NULL, NULL);

  55. memset(buf, 0, MAXSIZE);

  56. if (FD_ISSET(proxysd1, &rset)) {

  57. ret = recv(proxysd1, buf, MAXSIZE, 0);

  58. printf("%d --> %d proxy string:%s\n", proxysd1, proxysd2, buf);

  59. send(proxysd2, buf, ret, 0);

  60. }

  61. if (FD_ISSET(proxysd2, &rset)) {

  62. ret = recv(proxysd2, buf, MAXSIZE, 0);

  63. printf("%d --> %d proxy string:%s\n", proxysd2, proxysd1, buf);

  64. send(proxysd1, buf, ret, 0);

  65. }

  66. }


  67. return 0;

  68. }

我们来看一下它的工作过程。

首先起两个netcat,分别侦听两个不同的端口,然后运行proxy程序。在netcat终端敲入字符,就可以看到它被代理到另一个netcat终端的过程了:

我们看到,一次转发经过了两次系统调用(忽略select)和两次数据拷贝。

我们的demo旨在演示基于eBPF的sockmap对proxy转发的offload过程,所以接下来,我们对上述代码进行一些改造,即加入对sockmap的支持。

这意味着我们需要做两件事:

  1. 写一个在socket之间转发数据的eBPF程序,并编译成字节码。

  2. 在proxy代码中加入eBPF程序的加载代码,并编译成可执行程序。

首先,先给出ebpf程序的C代码:

  1. // sockmap_kern.c

  2. #include <uapi/linux/bpf.h>

  3. #include "bpf_helpers.h"

  4. #include "bpf_endian.h"


  5. struct bpf_map_def SEC("maps") proxy_map = {

  6. .type = BPF_MAP_TYPE_HASH,

  7. .key_size = sizeof(unsigned short),

  8. .value_size = sizeof(int),

  9. .max_entries = 2,

  10. };


  11. struct bpf_map_def SEC("maps") sock_map = {

  12. .type = BPF_MAP_TYPE_SOCKMAP,

  13. .key_size = sizeof(int),

  14. .value_size = sizeof(int),

  15. .max_entries = 2,

  16. };


  17. SEC("prog_parser")

  18. int bpf_prog1(struct __sk_buff *skb)

  19. {

  20. return skb->len;

  21. }


  22. SEC("prog_verdict")

  23. int bpf_prog2(struct __sk_buff *skb)

  24. {

  25. __u32 *index = 0;

  26. __u16 port = (__u16)bpf_ntohl(skb->remote_port);

  27. char info_fmt[] = "data to port [%d]\n";


  28. bpf_trace_printk(info_fmt, sizeof(info_fmt), port);

  29. index = bpf_map_lookup_elem(&proxy_map, &port);

  30. if (index == NULL)

  31. return 0;


  32. return bpf_sk_redirect_map(skb, &sock_map, *index, 0);

  33. }


  34. char _license[] SEC("license") = "GPL";

上述代码在内核源码树的 samples/bpf 目录下编译,只需要在Makefile中加入以下的行即可:

  1. always += sockmap_kern.o

OK,下面我们给出用户态的测试程序,实际上就是将我们最初的 proxy.c 增加对ebpf/sockmap的支持即可:

  1. // sockmap_user.c

  2. #include <stdio.h>

  3. #include <stdlib.h>

  4. #include <sys/socket.h>

  5. #include <sys/select.h>

  6. #include <unistd.h>

  7. #include <netdb.h>

  8. #include <signal.h>

  9. #include "bpf_load.h"

  10. #include "bpf_util.h"


  11. #define MAXSIZE 1024

  12. char buf[MAXSIZE];

  13. static int proxysd1, proxysd2;


  14. static int sockmap_fd, proxymap_fd, bpf_prog_fd;

  15. static int progs_fd[2];

  16. static int key, val;

  17. static unsigned short key16;

  18. static int ctrl = 0;


  19. static void int_handler(int a)

  20. {

  21. close(proxysd1);

  22. close(proxysd2);

  23. exit(0);

  24. }


  25. // 可以通过发送HUP信号来打开和关闭sockmap offload功能

  26. static void hup_handler(int a)

  27. {

  28. if (ctrl == 1) {

  29. key = 0;

  30. bpf_map_update_elem(sockmap_fd, &key, &proxysd1, BPF_ANY);

  31. key = 1;

  32. bpf_map_update_elem(sockmap_fd, &key, &proxysd2, BPF_ANY);

  33. ctrl = 0;

  34. } else if (ctrl == 0){

  35. key = 0;

  36. bpf_map_delete_elem(sockmap_fd, &key);

  37. key = 1;

  38. bpf_map_delete_elem(sockmap_fd, &key);

  39. ctrl = 1;

  40. }

  41. }


  42. int main(int argc, char **argv)

  43. {

  44. char filename[256];

  45. snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);

  46. struct bpf_object *obj;

  47. struct bpf_program *prog;

  48. struct bpf_prog_load_attr prog_load_attr = {

  49. .prog_type = BPF_PROG_TYPE_SK_SKB,

  50. };


  51. int ret;

  52. struct sockaddr_in proxyaddr1, proxyaddr2;

  53. struct hostent *proxy1, *proxy2;

  54. unsigned short port1, port2;

  55. fd_set rset;

  56. int maxfd = 10;


  57. if (argc != 5) {

  58. exit(1);

  59. }


  60. prog_load_attr.file = filename;


  61. signal(SIGINT, int_handler);

  62. signal(SIGHUP, hup_handler);


  63. // 这部分增加的代码引入了ebpf/sockmap逻辑

  64. bpf_prog_load_xattr(&prog_load_attr, &obj, &bpf_prog_fd);


  65. sockmap_fd = bpf_object__find_map_fd_by_name(obj, "sock_map");

  66. proxymap_fd = bpf_object__find_map_fd_by_name(obj, "proxy_map");


  67. prog = bpf_object__find_program_by_title(obj, "prog_parser");

  68. progs_fd[0] = bpf_program__fd(prog);

  69. bpf_prog_attach(progs_fd[0], sockmap_fd, BPF_SK_SKB_STREAM_PARSER, 0);


  70. prog = bpf_object__find_program_by_title(obj, "prog_verdict");

  71. progs_fd[1] = bpf_program__fd(prog);

  72. bpf_prog_attach(progs_fd[1], sockmap_fd, BPF_SK_SKB_STREAM_VERDICT, 0);



  73. proxysd1 = socket(AF_INET, SOCK_STREAM, 0);

  74. proxysd2 = socket(AF_INET, SOCK_STREAM, 0);


  75. proxy1 = gethostbyname(argv[1]);

  76. port1 = atoi(argv[2]);


  77. proxy2 = gethostbyname(argv[3]);

  78. port2 = atoi(argv[4]);


  79. bzero(&proxyaddr1, sizeof(struct sockaddr_in));

  80. proxyaddr1.sin_family = AF_INET;

  81. proxyaddr1.sin_port = htons(port1);

  82. proxyaddr1.sin_addr = *((struct in_addr *)proxy1->h_addr);


  83. bzero(&proxyaddr2, sizeof(struct sockaddr_in));

  84. proxyaddr2.sin_family = AF_INET;

  85. proxyaddr2.sin_port = htons(port2);

  86. proxyaddr2.sin_addr = *((struct in_addr *)proxy2->h_addr);


  87. connect(proxysd1, (struct sockaddr *)&proxyaddr1, sizeof(struct sockaddr));

  88. connect(proxysd2, (struct sockaddr *)&proxyaddr2, sizeof(struct sockaddr));


  89. key = 0;

  90. bpf_map_update_elem(sockmap_fd, &key, &proxysd1, BPF_ANY);


  91. key = 1;

  92. bpf_map_update_elem(sockmap_fd, &key, &proxysd2, BPF_ANY);


  93. key16 = port1;

  94. val = 1;

  95. bpf_map_update_elem(proxymap_fd, &key16, &val, BPF_ANY);


  96. key16 = port2;

  97. val = 0;

  98. bpf_map_update_elem(proxymap_fd, &key16, &val, BPF_ANY);


  99. // 余下的proxy转发代码保持不变,这部分代码一旦开启了sockmap offload,将不会再被执行。

  100. while (1) {

  101. FD_SET(proxysd1, &rset);

  102. FD_SET(proxysd2, &rset);

  103. select(maxfd, &rset, NULL, NULL, NULL);

  104. memset(buf, 0, MAXSIZE);

  105. if (FD_ISSET(proxysd1, &rset)) {

  106. ret = recv(proxysd1, buf, MAXSIZE, 0);

  107. printf("%d --> %d proxy string:%s\n", proxysd1, proxysd2, buf);

  108. send(proxysd2, buf, ret, 0);

  109. }

  110. if (FD_ISSET(proxysd2, &rset)) {

  111. ret = recv(proxysd2, buf, MAXSIZE, 0);

  112. printf("%d --> %d proxy string:%s\n", proxysd2, proxysd1, buf);

  113. send(proxysd1, buf, ret, 0);

  114. }

  115. }


  116. return 0;

  117. }

同样的,为了和eBPF程序配套,我们在Makefile中增加下面的行:

  1. hostprogs-y += sockmap

  2. sockmap-objs := sockmap_user.o

最后直接在 samples/bpf 目录下make即可生成下面的文件:

  1. -rwxr-xr-x 1 root root 366840 12月 20 09:43 sockmap

  2. -rw-r--r-- 1 root root 12976 12月 20 11:14 sockmap_kern.o

为了验证效果,我们起五个屏,下面是一个演示的过程截图和步骤说明:

可见,proxy转发数据流的逻辑通过一个eBPF小程序从用户态服务进程中offload到了内核协议栈。用户态的proxy进程甚至不会由于数据的到来而被wakeup,这是比sendfile/splice高效的地方。

从上面的demo可以看到,sockmap顾名思义可以对接两个socket,这是eBPF这把 “瑞士军刀” 专门针对socket的一个小器件,这完美解决了sendfile的in_fd必须支持mmap的限制:

demo的代码和演示就到这里,我们再一次看到了eBPF之妙!

附:eBPF-可编程内核利器

我先说下为什么我把eBPF看作一把瑞士军刀:

瑞士军刀,包含小巧的圆珠笔、牙签、剪刀、平口刀、开罐器、螺丝刀、镊子等... 


eBPF呢,它可以附着在xdp,kprobe,skb,socket lookup,trace,cgroup,reuseport,sched,filter等功能点,有人可能会说eBPF不如Nginx,不如OpenWRT,不如OVS,不如iptables/nftables...确实,但是这就好比说瑞士军刀不如AK47,不如东风-41洲际导弹,不如Zippo,不如张小泉王麻子,不如苏泊尔一样... 


eBPF和瑞士军刀一样,小而全是它们的本色( eBPF严格限制指令数量 ),便携,功能丰富,手艺人离不开的利器。

eBPF让 内核可编程 变的可能!

内核可编程是一个很有意思的事情,它使得内核的一些关键逻辑不再是一成不变的,而是可以通过eBPF对其进行编程,实现更多的策略化逻辑。

目前,eBPF已经密密麻麻扎进了Linux的各个角落,eBPF的作用点还在持续增多,迄至Linux 5.3内核,Linux内核已经支持如下的eBPF程序类型:

  1. enum bpf_prog_type {

  2. BPF_PROG_TYPE_UNSPEC,

  3. BPF_PROG_TYPE_SOCKET_FILTER,

  4. BPF_PROG_TYPE_KPROBE,

  5. BPF_PROG_TYPE_SCHED_CLS,

  6. BPF_PROG_TYPE_SCHED_ACT,

  7. BPF_PROG_TYPE_TRACEPOINT,

  8. BPF_PROG_TYPE_XDP,

  9. BPF_PROG_TYPE_PERF_EVENT,

  10. BPF_PROG_TYPE_CGROUP_SKB,

  11. BPF_PROG_TYPE_CGROUP_SOCK,

  12. BPF_PROG_TYPE_LWT_IN,

  13. BPF_PROG_TYPE_LWT_OUT,

  14. BPF_PROG_TYPE_LWT_XMIT,

  15. BPF_PROG_TYPE_SOCK_OPS,

  16. BPF_PROG_TYPE_SK_SKB,

  17. BPF_PROG_TYPE_CGROUP_DEVICE,

  18. BPF_PROG_TYPE_SK_MSG,

  19. BPF_PROG_TYPE_RAW_TRACEPOINT,

  20. BPF_PROG_TYPE_CGROUP_SOCK_ADDR,

  21. BPF_PROG_TYPE_LWT_SEG6LOCAL,

  22. BPF_PROG_TYPE_LIRC_MODE2,

  23. BPF_PROG_TYPE_SK_REUSEPORT,

  24. BPF_PROG_TYPE_FLOW_DISSECTOR,

  25. BPF_PROG_TYPE_CGROUP_SYSCTL,

  26. BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE,

  27. BPF_PROG_TYPE_CGROUP_SOCKOPT,

  28. };

一共26种类型,26个作用点。而在不久之前的Linux 4.19内核,这个数值也就22。可见eBPF吞噬内核的速度之快!

后面,我们还会看到eBPF在socket lookup机制所起的妙用。


浙江温州皮鞋湿,下雨进水不会胖。

(完)

      Linux阅码场原创精华文章汇总

觉得内容不错的话,别忘了右下角点个 在看 哦~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存